Skip to content

Define a exhaustive switch errorprone check for conjure sealed unions#3344

Open
kunalrkak wants to merge 4 commits intodevelopfrom
kkak/sealed-switch-errorprone
Open

Define a exhaustive switch errorprone check for conjure sealed unions#3344
kunalrkak wants to merge 4 commits intodevelopfrom
kkak/sealed-switch-errorprone

Conversation

@kunalrkak
Copy link

Before this PR

If a switch statement over a conjure sealed union type includes a default clause, the Java compiler will
not break when new union variants are added. This is undesirable as it doesn't force code owners to
explicitly acknowledge and handle new types. By using exhaustive switches without default clauses,
the compiler ensures all sealed union consumers must consciously decide how to handle new variants
before their code compiles again.

After this PR

==COMMIT_MSG==
Defines an errorprone check to encourage use of exhaustive switches for conjure sealed union types.
==COMMIT_MSG==

Possible downsides?

@changelog-app
Copy link

changelog-app bot commented Nov 10, 2025

Generate changelog in changelog/@unreleased

Type (Select exactly one)

  • Feature (Adding new functionality)
  • Improvement (Improving existing functionality)
  • Fix (Fixing an issue with existing functionality)
  • Break (Creating a new major version by breaking public APIs)
  • Deprecation (Removing functionality in a non-breaking way)
  • Migration (Automatically moving data/functionality to a new system)

Description

Defines an errorprone check to encourage use of exhaustive switches for conjure sealed union types.

Check the box to generate changelog(s)

  • Generate changelog entry

Comment on lines 89 to 94
// Check if it has a nested interface called "Known"
// Empty conjure unions won't have a Known interface, but they won't be used in switches either by definition
return ASTHelpers.getEnclosedElements(type.asElement()).stream()
.anyMatch(element -> element.getKind() == ElementKind.INTERFACE
&& element.getModifiers().contains(Modifier.SEALED)
&& "Known".equals(element.getSimpleName().toString()));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we instead check whether the type is annotated with @Generated("com.palantir.conjure.java.types.UnionGenerator")?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the generated annotation has "source" retention, wouldn't this fail to match on sealed conjure unions that are published by other services and consumed in the repo running errorprone? It should work for local conjure unions since errorprone runs alongside java compilation.

Comment on lines +37 to +38
case Circle circle -> System.out.println("Circle");
default -> System.out.println("Unknown");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case Circle circle -> System.out.println("Circle");
default -> System.out.println("Unknown");
case Circle circle -> System.out.println("Circle");
case Square square -> System.out.println("Square");
case Unknown unknown -> System.out.println("Unknown");
default -> System.out.println("Unknown");

Is there a reason not to add all the test cases here? (if we're just testing the behavior with regards to the presence of default, this seems more likely to be the test we want - we can also separately test the check behaves correctly in the absence of some statement, should we want to)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe there's already an existing check that checks for an "unnecessary" default clause if you've written an exhaustive switch statement over an enum/sealed type (not applied in this test, but also not exactly the goal of this check). In this example the default case is necessary since we don't go over all the possible subvariants, but still gets flagged by the check.

@pkoenig10
Copy link
Member

This feels somewhat misguided, for reasons similar to those mentioned in #3353 (comment).

It's pretty common to want to handle one case of a union type and return or throw otherwise. This would make those cases much more verbose.

This also means that adding a union variant is going to be a compile break for every consumer of an API which seems undesirable.

@kunalrkak
Copy link
Author

@pkoenig10 - I brought this up with the team and we agree that there are valid cases where a developer would want to handle a single variant of a union, and where using a default clause makes sense.

We believe exhaustive switch statements are valuable from a safety perspective to make consumers aware when new variants are added to a union via a compile break, especially when the conjure objects are published by another repository. This ensures that they are forced to consciously evaluate the new variant and explicitly decide how to handle it, rather than having it silently fall through to a default clause which may or may not be appropriate. Here's a simple but illustrative example of this that @mpritham found: https://courses.cs.cornell.edu/cs3110/2021sp/textbook/data/catch_all_cases.html

Obviously this creates tension with the pattern that you described - I'm not really sure what the best balance is between code safety and developer experience. We thought developers could opt-into using default clauses by just suppressing the errorprone warning, but if this is quite common I imagine that would be frustrating.

Definitely open to feedback if this behavior is undesirable, but it is at least consistent with how older union types worked, given adding new variants broke existing visitor code.

@pkoenig10
Copy link
Member

We thought developers could opt-into using default clauses by just suppressing the errorprone warning, but if this is quite common I imagine that would be frustrating.

I don't think we should be introducing checks where users are required to suppress the check for some common cases. This teaches users that suppressing warnings is a normal thing to do, increasing the likelihood that they suppress warnings for more important checks.

@kunalrkak
Copy link
Author

Fair point, we could be less prescriptive here and instead just warn devs of the potential unexpected consequences of this. We're going to pivot this check to just flagging cases where the switch is already exhaustive and a default case is still included, since that would be logically unnecessary and still would be susceptible to the safety pitfalls of having a default clause.

@kelvinou01
Copy link
Contributor

DO NOT MERGE (YET)

We're currently moving all the error-prone infrastructure in gradle-baseline (including baseline-error-prone, baseline-nullaway) to https://github.com/palantir/baseline-error-prone. This PR should be opened in the new repository once setup is completed.

@kelvinou01
Copy link
Contributor

kelvinou01 commented Dec 5, 2025

Hey there, our error-prone infrastructure has been moved to https://github.com/palantir/baseline-error-prone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants